Repository navigation
fix: Reject deleted flags and segments in flag overrides - #574
Open
kinyoklion wants to merge 4 commits into
Open
kinyoklion wants to merge 4 commits into
kinyoklion wants to merge 4 commits into
Conversation
Member
Author
|
bugbot review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit b494eb1. Configure here.
keelerm84
approved these changes
Nov 10, 2026
kinyoklion
added a commit
that referenced
this pull request
Nov 10, 2026
<!-- TERMAG_PR_BODY_BEGIN --> ## Summary This is the third step of the flag overrides port described by the OVERRIDE specification. It is based on the model marker branch because the phases are stacked; retarget to `feat/overrides` once that branch merges. **The override layer** (`ldclient/impl/overrides`). `OverrideLayer` is the override store: it holds marked shallow copies of the flag and segment definitions a source supplies, keyed by key, and is replaced wholesale on each update, so the layer's contents are exactly one snapshot at any instant and an empty snapshot clears it. Dictionaries are decoded with the model constructors, and an invalid definition raises rather than being defaulted. A definition whose key differs from its key in the snapshot raises too, and the layer is left unchanged: evaluation reads an entry by its snapshot key, while the all-flags state and events use the definition's key. `OverrideStoreView` is the overlay at the store read boundary: a read of a flag or segment returns the override entry when one exists and the LaunchDarkly entry otherwise, whatever the state of the base store, and an enumeration is the union of both with the override entry winning, including over a deleted item. A failure of the base enumeration propagates, as it does without the overlay. When that read fails, `all_flags_state` logs the failure as before and, if the override layer holds flags, returns a state of those flags marked invalid rather than an empty one: overrides only add to the state, and a client-side SDK bootstrapped from it uses its flags whatever `$valid` says, so the overridden values still reach it. With no override flags the state is the same invalid, empty state as without a source. The sink stops accepting snapshots when the client is closed, and when the source fails to start it also clears what the source supplied. `OverrideSinkImpl` applies each snapshot as one serialized operation and notifies flag change listeners of every flag whose merged-view evaluation may have changed: entries added, removed, or changed, plus every flag that depends on them through prerequisites or segment references. An identical snapshot notifies nothing. **LaunchDarkly changes reach override dependents.** The layer keeps a dependency graph of its entries, rebuilt on each replacement, and the store (sync and async) expands every changed LaunchDarkly item through both its own graph and the layer's, to a fixpoint. A change to a prerequisite flag or a segment that an override entry references therefore notifies that entry's flag, the same way dependent flags are notified for LaunchDarkly data, and a chain may cross between LaunchDarkly data and overrides any number of times. Without an override source the store's behavior is unchanged. **Configuration and wiring.** `OverrideSource` and `OverrideSink` are public protocols in `ldclient.interfaces`, so a custom source can be supplied. `OverrideSourceBuilder` and `DataSystemConfig.override_source` (also on `AsyncDataSystemConfig`) plus `ConfigBuilder.overrides(...)` make the source an option of the FDv2 data system. The source is built with the data system, so invalid configuration raises from the client constructor like any other invalid component configuration. It starts before the data system's own threads and its initial load completes before the constructor returns; it is closed when the client is closed, after the sink stops accepting snapshots, and an exception from its `close` is logged. An exception from its `start` does not fail the constructor or `start`: the data system logs it, closes the source, clears any snapshot the source supplied before failing, and the client runs without overrides. The SDK's own file-based source does not raise from `start`; a custom source could. The source is not built or started when the client is offline, and the SDK logs a warning that it is not used. The data system's `store` becomes the overlay when a source is configured, so evaluation, prerequisite and segment resolution, and the all-flags read all see override precedence. The override source has no effect on initialization status, data availability, or data source status. The legacy FDv1 data system reports no override source. When the data system fails to start, the client constructor stops each component in turn and logs any failure to stop, so the event processor still stops and the caller receives the start failure. **The client facade.** Both `LDClient` and `AsyncLDClient` consult the override layer before the not-initialized short-circuit: an overridden flag is served before the client has LaunchDarkly data, and a flag the layer does not hold takes exactly the path it took before this feature, so a configured but empty source changes nothing with the same warning and event as before. The all-flags state reads through the overlay. While the client has no LaunchDarkly data it contains only the flags the layer holds, read from the layer rather than the overlay so that LaunchDarkly data in a store without an init marker stays out (with a once-per-client warning), and it is invalid when the layer holds no flags. Without a source this state is empty, because the SDK reports no flags before the store is initialized, so the override flags only add to what it reports; overrides never remove a flag from the state. An evaluation that fails with an unexpected exception reports the `EXCEPTION` reason with `overrideAffected` when the flag came from the override layer, in a single evaluation and in the all-flags state (OVERRIDE 1.5.4); the marker is read through `is_override_definition`, so the error path cannot fail on an unexpected store item. On the async client, override change notifications are marshalled onto the event loop, like every other change notification. **The async client with a persistent store.** The async client works out which flags an override change affects from the in-memory store, the only store it can read synchronously (`async_fdv2.py`). While a persistent store is the active store, a change to an override notifies the overridden flags, but not LaunchDarkly flags that depend on them through a prerequisite or a segment. The documentation of `AsyncDataSystemConfig.override_source` says so. **Left to later PRs in the stack.** - `all_flags_state` still keeps `trackEvents`, `trackReason` and `debugEventsUntilDate` for override-affected flags (OVERRIDE 1.4.1.1). The events step, #528, clears them. - An override entry marked as deleted is rejected by #574, which is stacked on top of the stack (launchdarkly/sdk-specs#288). **Tests.** Unit tests for the layer, overlay (sync and async), and sink; client tests for the gate, source lifecycle, construction errors, offline, precedence, prerequisite and segment overrides, all-flags state, and flag change and flag value change listeners, for both clients; and a runner for the OVERRIDE specification test vectors (`ldclient/testing/testdata/override-vectors/vectors.json`) through the full client stack. The vector runner's per-evaluation summary marker assertion is added with the events step. [SDK-3250](https://launchdarkly.atlassian.net/browse/SDK-3250) [SDK-3250]: https://launchdarkly.atlassian.net/browse/SDK-3250?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Adds **experimental flag/segment overrides** that can take precedence over LaunchDarkly data at evaluation time, wired through FDv2 (sync and async). > > **Configuration:** Optional `override_source` on the data system (`ConfigBuilder.overrides(...)`, `OverrideSourceBuilder`, public `OverrideSource` / `OverrideSink` protocols). Offline clients skip building/starting the source. > > **Runtime:** An `OverrideLayer` holds snapshot replacements; `OverrideStoreView` overlays the feature store so per-key reads prefer overrides; `OverrideSinkImpl` applies updates and fans out **flag change** notifications via merged-view dependency diffing. The store can register the layer as **external dependencies** so LaunchDarkly prerequisite/segment updates also notify affected override flags. > > **Clients:** `LDClient` / `AsyncLDClient` read the override layer **before** the not-initialized default path and adjust **`all_flags_state`** to return override-only bootstrapping when LD data is unavailable (with once-per-client warnings). Evaluation errors set **`overrideAffected`** when the definition came from overrides. Sync client **rolls back components** if data system start fails. > > **Tests:** Layer/overlay/sink units, broad sync+async client scenarios, store cross-graph notification, and OVERRIDE **spec vectors** (`override-vectors/vectors.json`). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0489514. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- TERMAG_PR_BODY_END --> <!-- termag: box=i-0575d1c54853058d7 project=Overrides --> <sub>Opened from termag</sub>
kinyoklion
force-pushed
the
rlamb/overrides-python-reject-deleted
branch
from
November 10, 2026 22:24
b494eb1 to
874d21d
Compare
kinyoklion
force-pushed
the
rlamb/overrides-python-file-source-paths
branch
from
November 10, 2026 22:24
a1bda43 to
5981654
Compare
FileOverrideSourceBuilder passed anything other than a str to list(), so a single pathlib.Path raised TypeError from the constructor. A str or an os.PathLike is now one path, and every path is converted with os.fspath. A path that does not name a string path raises TypeError.
A bare bytes or bytearray value is a single path, and a TypeError from os.fspath is replaced by the builder's own message, so every rejected path is named. The type hint is Sequence, so that type checkers reject a set, whose order is not fixed. Tests cover a path object whose string form differs from its path and every rejected value.
The override layer never holds a deleted item (OVERRIDE 1.1.1.1). A snapshot that contains a flag or segment marked as deleted, as a dictionary or a model object, is rejected whole: the layer is unchanged, no change notifications are sent, the rejection is logged at Error level naming the kind and the key, and the sink raises ValueError (1.2.8.1). The file-based override source detects such an entry when it parses a file, before it calls the sink, and fails the reload as for a file that cannot be parsed: the previous overrides stay, the failure names the file and the key, and the retry applies (1.3.6.2). An entry with "deleted": false still loads. The ordinary file data sources do not use this code and keep accepting deleted entries; tests pin that.
kinyoklion
force-pushed
the
rlamb/overrides-python-reject-deleted
branch
from
November 10, 2026 23:29
874d21d to
aaa1188
Compare
kinyoklion
force-pushed
the
rlamb/overrides-python-file-source-paths
branch
from
November 10, 2026 23:29
5981654 to
154b5b8
Compare
Base automatically changed from
rlamb/overrides-python-file-source-paths
to
feat/overrides
November 10, 2026 23:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requirements
async_*siblings (or N/A). The fix is in the override layer and sink, whichLDClientandAsyncLDClientshare. The tests cover both clients.Related issues
rlamb/overrides-python-file-source-paths) as a new PR, so the PRs below it do not change.Describe the solution you've provided
Before this change, a flag or segment with
"deleted": truewent into the override layer, from a custom source or from an override file. The file source counted the entry as an override in effect. The overlay served it, andvariation()andvariation_detail()raisedAttributeError: _track_eventsinto the application, in bothLDClientandAsyncLDClient. A flag that depended on it evaluated with reasonEXCEPTIONand withoutoverrideAffected.After this change:
Rejected an override snapshot; the overrides in effect stay: flag "f" is marked as deleted; an override is removed by leaving it out of the snapshot.set_overridesraisesValueError, as it already does for an invalid definition, so the data system handles both the same way. If a source lets the error propagate out ofstart, the existing start-failure handling applies: the error is logged, the source is closed, and the client runs without overrides. A rejected later update keeps the overrides in effect. The error goes back to the source's own caller.Unable to load flag data: flag "f" in /path/overrides.json is marked as deleted; remove the entry to remove its override. The existing retry applies."deleted": falseis an ordinary definition and still loads.FileOverrideSourceBuilderandOverrideSink.set_overrideseach get one user-level sentence.The ordinary file data sources are unchanged on purpose.
Files.new_data_sourceandFiles.new_data_source_v2do not use the override source's parsing code, and they still accept deleted entries. Rejecting deleted entries there would be a breaking change, so it is left for a later major version. New tests intest_file_data_sources_pinned_behavior.pypin the current behavior: the FDv1 source stores such entries as deleted, and the FDv2 source passes them on unchanged.Tests
testing/impl/test_overrides.py: the layer and the sink reject a deleted flag and a deleted segment, each as a dictionary and as a model, with and without listeners. In every case the layer is unchanged, nothing is notified, exactly one Error is logged, and the next snapshot is accepted. A definition withdeleted: falseis accepted.testing/test_ldclient_overrides.pyandtesting/test_async_client_overrides.pyuse a custom source. If it supplies a deleted flag at start, the client serves LaunchDarkly data for the flag and its dependent. If it supplies one in a later update,ValueErroris raised to the source, the override and the dependent'soverrideAffectedreason stay, and nothing is notified. In neither case dovariation()orvariation_detail()raise.testing/integrations/test_file_override_source.py:deleted: falseloads.testing/integrations/test_filedata.py:load_filesandread_fileraiseDeletedEntryError, aFileReadError, and its message names the file and the key.deleted: falseloads.testing/integrations/test_file_data_sources_pinned_behavior.py: pins that the ordinary data sources are unchanged.I ran every rejection test against the base commit without the fix, and each one fails there. The
deleted: falsetests and the pins pass both with and without the fix, as expected.How I ran them, with the clone's venv and this worktree on
PYTHONPATH:Describe alternatives you've considered
Additional context
Python CI runs only for PRs based on
mainorfeat/**, so it does not run on this PR.Opened from termag
Note
Overview
Override snapshots and file-based override loads now reject any flag or segment with
"deleted": true, instead of accepting them into the override layer (which could break evaluation).File parsing (
filedata) addsDeletedEntryErrorand_reject_deleted_entries, run after parse inread_file/_parse_file, so a reload with a deleted entry fails like a parse error and prior overrides stay in effect.Override application adds
DeletedItemErrorinOverrideLayer.set_all;OverrideSinkImpllogs and re-raises so nothing is applied and listeners are not notified. Public docs onFileOverrideSourceBuilderandOverrideSink.set_overridesstate that removal is done by omitting the entry, not by marking it deleted.Legacy file data sources (FDv1/FDv2) are unchanged and still accept deleted entries; new pinned tests document that behavior.
Tests cover layer, sink, sync/async clients, file override source (JSON/YAML), and
load_files.Reviewed by Cursor Bugbot for commit c6dcabc. Bugbot is set up for automated code reviews on this repo. Configure here.